Skip to content

fix(metro): compile the native stylesheet from the authored CSS, never its web build - #461

Open
YevheniiKotyrlo wants to merge 1 commit into
nativewind:mainfrom
YevheniiKotyrlo:fix/metro-native-stylesheet-input
Open

YevheniiKotyrlo wants to merge 1 commit into
nativewind:mainfrom
YevheniiKotyrlo:fix/metro-native-stylesheet-input

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Problem

The Metro transformer compiles the web build of a stylesheet for a device. That build is @expo/metro-config's transformCss: PostCSS, Sass, then lightningcss against the project's browserslist, or browserslist's defaults when the project declares none. So the native compiler receives CSS lowered for browsers. Measured on a Tailwind v4 entry:

the lowering on native
:dir(rtl) becomes :is(:lang(ae), :lang(ar), …) the compiler has no reading for it, so every rtl: / ltr: rule is dropped (#453)
oklch() becomes lab(), plus a hex-fallback :root beside a @supports copy every root variable is declared twice, so inlineVariables inlines none and each colour utility compiles to a runtime lookup
transform-origin: left top 30px is re-serialised it compiles to ["0%", 30, 0] instead of [0, 0, 30]

All of it moves with the browserslist.

Solution

The transformer runs the project's PostCSS and Sass through the modules Expo's web transform calls, in the same order, and compiles their output. No browser build is produced for a native stylesheet.

  • transformPostCssModule resolves and caches the project's postcss.config.* as the web transform does, so a plugin runs for a device exactly when it runs for a browser.
  • The web transform's lightningcss pass also collected @import dependencies, but the transformer already discarded them.

compile()'s own lightningcss pass sets no targets and keeps :dir(); it was handed the wrong input.

Both helpers are imported from @expo/metro-config/build/transform-worker/, which is not listed as public API. sass becomes a dev dependency, so Expo's declarations type the Sass helpers and the tests run Dart Sass.

Tests

  • src/__tests__/metro/native-stylesheet-input.test.ts drives the real transform against a stand-in for Expo's worker.
    • The stand-in's web branch runs the project's PostCSS, Sass for a .scss or .sass, then lightningcss against chrome: 100, which predates :dir(), so the lowering happens for real.
    • The project is a postcss.config.json naming @tailwindcss/postcss, a @theme colour in oklch(), and bg-[#00f] rtl:bg-[#f00] bg-brand.
    • Two cases are the bug's shape: rtl:bg-[#f00] keeps its dir condition while the worker is never asked for a web build, and bg-brand inlines to a literal.
    • Six pin what must not move: the injected stylesheet, the emptied and skipCached output, and three requests that must reach the worker untouched (a web build, a non-stylesheet module, a stylesheet requested as an asset).
  • src/__tests__/metro/native-stylesheet-plain-project.test.ts is a project with no PostCSS config, in its own file because Expo resolves a project's pipeline once per process. An indented .sass beside its CSS is compiled by Dart Sass.
  • src/__tests__/metro/native-stylesheet-sass.test.ts compiles a .scss with Expo's helper and Dart Sass. The project's PostCSS plugin appends a rule whose colour is a Sass variable, so that colour reaching the stylesheet shows Sass compiling PostCSS's output, in Expo's order. Its web build reaches the worker untouched.
  • Each file runs the real transform in a beforeAll, under one 60 s timeout from the worker stand-in, since the transform can outlast jest's 5 s default.

On main, 4 of the 15 cases fail, each where the bug shows; all pass here. Four mutations each fail their own cases: skipping PostCSS, skipping Sass, handing Sass the authored source, and one Sass syntax for every file.

Verification

On Ubuntu with the workflow's steps - build, lint, typecheck, yarn test, node --test node/__tests__/*.mjs, both library builds, the web export, the unstaged-changes checks and commitlint - everything passes, and the iOS dev build passes with Xcode 27.1, where the workflow pins 26.6.

Device evidence, before and after: #461 (comment)

Related

Closes #453. That report places the lowering inside compile(); it happens one hop earlier.

Merge order

#493 lands first. This compiles a native stylesheet without the pass in Expo's CSS worker that drops what lightningcss cannot parse, so on its own a rule such as @media (foo bar), color: var() or .b > { color: blue } throws in compile() and fails a Metro build that main passes with a warning. With #493 each is dropped with a warning, as on main.

@YevheniiKotyrlo

YevheniiKotyrlo commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — The light-dark() bar is an empty outline. The compiler receives CSS lowered for browsers, where light-dark() is a pair of variables React Native discards.

FIXED — The light-dark() bar paints its light arm, red, like the control. The compiler receives light-dark() as authored.

before — 3.1.0-rc.0 after — with this PR

Android 16 emulator (API 36, GLES on the host GPU), 1140×2400 @ 480dpi, both frames from one run. before is the published 3.1.0-rc.0 plus what my test app needs from my other patches to run: the native and native-internal subpath exports, PR 459, cascade-layer ranking, and a shim that compiles a class-qualified :root inert. after adds the rest of my open PRs, this one included. One of them shows on every screen: PR 417 narrows a var()-valued font-family to one family, so the after sets its text in the theme's face where the before falls back to the platform's.

…r its web build

The transformer compiled Expo's web build of the stylesheet, which
lightningcss had lowered for the project's browserslist: `:dir()` became
`:lang()` lists the compiler cannot read, `oklch()` gained a second
`:root`, and the native output moved with the browserslist. It now runs
the project's PostCSS and Sass, in the order Expo's web transform runs
them, and compiles their output.

sass becomes a dev dependency, so the tests compile the Sass arm with
Dart Sass and the helpers take their types from Expo's declarations.

Closes nativewind#453
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the fix/metro-native-stylesheet-input branch from c9ceaa4 to 5eba16a Compare October 27, 2026 22:20
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 27, 2026 22:44

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

rtl:/ltr: variants silently dropped — lightningcss downcompiles :dir() to :lang() which is unhandled

1 participant